Skip to content

feat(shadow): record validated value age on shadow match and mismatch - #136

Merged
lan17 merged 2 commits into
mainfrom
claude/dialcache-shadow-miss-staleness-df6603
Aug 14, 2026
Merged

feat(shadow): record validated value age on shadow match and mismatch#136
lan17 merged 2 commits into
mainfrom
claude/dialcache-shadow-miss-staleness-df6603

Conversation

@lan17

@lan17 lan17 commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What

When shadow validation delivers a match or mismatch verdict, DialCache now records the age of the validated cached value — the observing process's epoch clock minus the served frame's createdAtMs, in seconds, clamped at zero. A confirmed mismatch age answers the question this feature exists for: how long had the stale value been readable when validation caught it? The match series is the baseline that makes the mismatch histogram interpretable.

  • New optional adapter hook observeShadowValueAge(labels, seconds) with the existing shadow labels (cacheNamespace, useCase, keyType, outcome). It does not gate shadow eligibility; only shadowValidation does.
  • Prometheus: dialcache_shadow_value_age_histogram with buckets spanning 1s to 7d.
  • Datadog: dialcache.shadow.value_age (distribution or histogram per the configured observation type).
  • Outcomes that deliver no verdict on a retained value (superseded, filled, every error, timeout) record no age.

How (breaking)

Every Redis frame already carried an 8-byte createdAtMs header — Redis server time for tracked writes (stamp script), the writer's informational client clock for untracked ones — but both decoders stripped it before it could cross the client boundary.

BREAKING CHANGE: decodeRedisFrame, decodeTrackedRedisFrame, and DialCacheRedisClient.read() now return DecodedRedisFrame | null ({ payload, createdAtMs }) instead of the bare payload. The bundled node-redis and Valkey GLIDE adapters pass decoder results through and needed no code changes; custom clients must return the decoded frame. Per the pre-1.0 release policy this ships as a minor version.

Design notes:

  • Watermark fencing is unchanged and still happens inside decodeTrackedRedisFrame; createdAtMs is consumed only for observability.
  • The mismatch confirmation (C1) equality check remains byte-equality on payloads — identical bytes rewritten by a concurrent writer still confirm the mismatch verdict, exactly as before.
  • Age uses the epoch clock (Date.now()), since frame stamps are epoch-based. It mixes clocks (server vs. client), so negative skew clamps to zero and the metric is documented as coarse operational evidence.
  • The age is captured at verdict time inside the shadow flight; the retained-frame release path (flight.cachedFrame = null) is unchanged, and the packaged GC test still proves a timed-out flight releases its payload.

Validation

  • pnpm typecheck
  • pnpm test — 516/516 (new coverage: match/mismatch age with pinned clocks, future-stamp clamp to zero, no age on superseded/filled, both metric adapters, decoder timestamp round-trips)
  • pnpm build
  • pnpm test:package ✓ (packaged ESM/CJS round-trip assertions updated to the frame shape)
  • pnpm test:integration — 139/139 against real Redis + Redis Cluster via testcontainers, including an end-to-end assertion that real shadow runs emit exactly two age observations (match, mismatch) and none for superseded

Migration notes for custom Redis clients

Nothing stored in Redis changes: the frame wire format, stamps, and fencing are byte-identical, and mixed 0.19/0.20 fleets interoperate in both directions. The break is confined to the in-process contract.

  • Bundled adapters (createNodeRedisDialCacheClient, createValkeyGlideDialCacheClient): no action beyond the version bump.
  • Custom DialCacheRedisClient implementations / direct decoder callers: read() and the dialcache/redis-protocol decoders now return DecodedRedisFrame | null ({ payload, createdAtMs }). TypeScript surfaces this as a compile error. Two plain-JS failure modes to know:
    • Returning the bare payload → every remote read fails open to a miss with serialization_load error metrics (loud, correctness-safe).
    • Returning { payload } without createdAtMs → serving and shadow validation work normally; only the value-age observation is affected, and DialCache skips recording non-finite ages rather than forwarding them to the metrics backend.
  • Untracked writers must stamp real client time (Date.now()), not a constant — the stamp now feeds observeShadowValueAge on shadow verdicts.

Review

Ran a six-lane adversarial review (correctness, tests, simplicity, architecture, contracts, reliability + two-stage holistic audit) against cf087d0; four low-severity findings, all addressed in the follow-up commit: stale "untracked reads never consult it" contract docs, a Number.isFinite guard so an out-of-contract client stamp cannot poison backend histogram sums, a divergent-stamp confirmation test pinning mismatch-age provenance to the original frame, and a Prometheus histogram sum assertion.

🤖 Generated with Claude Code

@lan17 lan17 changed the title feat(shadow)!: record validated value age on shadow match and mismatch feat(shadow): record validated value age on shadow match and mismatch Aug 14, 2026
lan17 added 2 commits August 14, 2026 13:38
Shadow validation now measures how stale a cached value was when a
verdict was delivered. Every Redis frame already carried an 8-byte
createdAtMs header (Redis server time for tracked writes, the writer's
client clock for untracked ones), but both decoders discarded it. The
decoders and DialCacheRedisClient.read() now return a DecodedRedisFrame
({ payload, createdAtMs }), the shadow flight retains the frame, and a
match or mismatch verdict records now - createdAtMs in seconds, clamped
at zero, through the new optional observeShadowValueAge adapter hook.

The Prometheus adapter exposes dialcache_shadow_value_age_histogram
with 1s..7d buckets and the Datadog adapter emits
dialcache.shadow.value_age, both labeled by outcome. Outcomes that
deliver no verdict on a retained value (superseded, filled, errors,
timeout) record no age, and the hook does not gate shadow eligibility;
only shadowValidation does.

BREAKING CHANGE: decodeRedisFrame, decodeTrackedRedisFrame, and
DialCacheRedisClient.read() return DecodedRedisFrame | null instead of
the bare payload. Custom Redis clients must return the decoded frame;
the bundled node-redis and Valkey GLIDE adapters inherit the change
unchanged. Pre-1.0 policy releases this as a minor version.
Review-loop findings on cf087d0, all four addressed:

- Skip the value-age observation when the computed age is non-finite. An
  out-of-contract custom client stamping NaN (or omitting createdAtMs in
  plain JS) previously flowed through Math.max(NaN, 0) into the metrics
  backend, permanently poisoning prom-client histogram sums with no
  diagnostic surface; the write side already RangeErrors the same field.
- Correct the two stale "untracked reads never consult it" contract docs:
  the stamp is never consulted for serving or miss decisions, but it now
  surfaces on the decoded frame and feeds the shadow value-age
  observation, so untracked writers must stamp real client time. Also
  tighten DecodedRedisFrame payload wording (compression envelope) and
  the verdict-time phrasing in metrics docs, Prometheus help, and README.
- Pin mismatch-age provenance: the confirmation read now republishes
  identical bytes with a fresh stamp while the test still expects the
  original frame's 90s age, so regressing to the confirmation frame's
  stamp fails instead of passing silently.
- Assert the Prometheus shadow value-age histogram sum (42) and its exact
  label set, closing the one unpinned numeric pass-through.
@lan17
lan17 force-pushed the claude/dialcache-shadow-miss-staleness-df6603 branch from 53da96d to 44801f7 Compare August 14, 2026 20:38
@lan17
lan17 merged commit 3305f4a into main Aug 14, 2026
5 checks passed
@lan17
lan17 deleted the claude/dialcache-shadow-miss-staleness-df6603 branch August 14, 2026 21:32
Comment thread src/prometheus.ts
const TIMER_BUCKETS = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
const SIZE_BUCKETS = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000];
const RATIO_BUCKETS = [0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1];
// Value ages span seconds to the 365-day TTL ceiling: 1s..15m, then 1h, 3h, 12h, 1d, 3d, 7d.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this include buckets beyond 7 days? 604_800 is only 7 days, while DialCache supports much longer cache TTLs. Everything else will be inf.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nah

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants